Skip to content

chore: [SDK-4406] prepare Unity demo app for Appium E2E tests#869

Open
fadi-george wants to merge 54 commits into
mainfrom
fadi/sdk-4406-use-appium-tests-for-unity
Open

chore: [SDK-4406] prepare Unity demo app for Appium E2E tests#869
fadi-george wants to merge 54 commits into
mainfrom
fadi/sdk-4406-use-appium-tests-for-unity

Conversation

@fadi-george
Copy link
Copy Markdown
Collaborator

@fadi-george fadi-george commented May 15, 2026

Description

One Line Summary

Prepare the Unity demo app for Appium E2E test automation by adding accessibility bridges, stabilizing UI element naming, and improving iOS/Android test reliability.

Details

Motivation

SDK-4406: enable Appium-driven E2E tests against the Unity demo app. Unity's UI Toolkit does not expose native accessibility identifiers, so Appium cannot reliably locate elements on iOS or Android. This PR adds native accessibility bridges (iOS and Android), standardizes UI element names, and fixes a number of demo-side bugs uncovered while wiring up the test harness.

Scope

  • Only examples/demo/ is touched. No SDK source changes.
  • Adds a C# AccessibilityBridge plus iOS (OneSignalDemoKeyboard.mm) and Android (OneSignalUnityE2EAccessibility.java) native helpers used to expose UI elements to Appium.
  • Adds an iOS signing post-processor and arm64 simulator config so the demo builds cleanly on Apple Silicon CI.
  • Demo refactors: removes the in-app LogView/LogManager (replaced with Debug.Log), renames TrackEventSectionController to CustomEventsSectionController, consolidates upsert helpers, replaces the loading overlay with inline loading states, and tightens dialog/keyboard behavior.
  • Fixes stale Android UI Toolkit click targets after dialogs are dismissed, so repeated modal flows route clicks to the current visible element.
  • Gates Android WebView debugging to active IAM display windows in E2E mode, which prevents dismissed IAM WebView contexts from leaking into later Appium checks.
  • Bumps Unity editor version and demo packages.

Testing

Manual testing

  • Built and ran the demo on iOS Simulator (arm64, Apple Silicon) and Android emulator.
  • Verified Appium can locate and interact with home screen sections, dialogs, toggles, and toasts via the accessibility bridge.
  • Smoke-tested core demo flows: login/logout, aliases, tags, triggers, push send, IAM, custom events, location, live activities.
  • Ran Unity Android locally: ./run-local.sh --sdk=unity --platform=android --spec="{01_,02_,03_}".
  • Re-ran Unity Android locally with --skip-build for 01_ through 12_; all numbered specs passed.

Affected code checklist

  • None - demo app only, no SDK changes.

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing (demo prep for Appium E2E)
  • No public API changes

Testing

  • Manually tested on iOS Simulator and Android emulator
  • No SDK behavior changes, so no new unit tests needed

Final pass

  • Code reviewed

Made with Cursor

Comment thread examples/demo/Assets/Scripts/UI/Dialogs/MultiPairInputDialog.cs Outdated
Comment thread examples/demo/Assets/Scripts/Services/AccessibilityBridge.cs Outdated
Comment thread examples/demo/Assets/Scripts/UI/Sections/SectionBuilder.cs
Comment thread examples/demo/Assets/Scripts/UI/Sections/SectionBuilder.cs Outdated
fadi-george and others added 8 commits May 14, 2026 23:54
Row TextField names were derived from `_rows.Count` at construction time,
but RemoveRow does not renumber survivors. After Add+Remove(middle)+Add,
the new row's name collided with an existing survivor's name, and
AccessibilityBridge.WalkAndUpsert dedupes by name (first-wins) — so the
new row was silently dropped from the a11y tree and Appium taps landed
on the stale row. Switch to a monotonic `_nextRowIndex++` so names are
permanently unique regardless of remove ordering.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@fadi-george
Copy link
Copy Markdown
Collaborator Author

@claude review

Comment thread examples/demo/Assets/Scripts/UI/SecondaryScreenController.cs
Comment thread examples/demo/Assets/App/Editor/iOS/BuildPostProcessor.cs
Comment on lines +728 to +741
private static string AndroidNativeRole(VisualElement el)
{
if (AndroidClickTargets.ContainsKey(el))
return "button";

return el switch
{
TextField => "input",
BaseBoolField => "toggle",
OneSignalDemo.UI.SwitchToggle => "toggle",
Button => "button",
_ => "text",
};
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 🟡 MultiSelectRemoveDialog registers each Toggle via AccessibilityBridge.RegisterE2ETapTarget (MultiSelectRemoveDialog.cs:65-69), which adds the Toggle to AndroidClickTargets. But AndroidNativeRole (AccessibilityBridge.cs:728-740) checks AndroidClickTargets first and returns "button" — short-circuiting the BaseBoolField => "toggle" arm — so each remove_checkbox_{key} Toggle is published with role=button and the Java overlay creates an E2ETextView instead of an E2ECheckBox. UiAutomator2 then sees android.widget.TextView (not CheckBox), isChecked()/UiSelector().checked(true)/getAttribute("checked") no longer reflect Toggle state, and applyText writes "1"/"0" as plain text instead of calling setChecked(). Click routing still works via the Q<BaseBoolField>(id) path, so the author's specs pass, but the semantic is wrong and future state assertions on these toggles can't work. Fix: run the type switch first and fall through to the AndroidClickTargets check only when the element isn't already a typed control.

Extended reasoning...

What the bug is

AccessibilityBridge.AndroidNativeRole (AccessibilityBridge.cs:728-740) is currently:

private static string AndroidNativeRole(VisualElement el)
{
    if (AndroidClickTargets.ContainsKey(el))
        return "button";

    return el switch
    {
        TextField => "input",
        BaseBoolField => "toggle",
        OneSignalDemo.UI.SwitchToggle => "toggle",
        Button => "button",
        _ => "text",
    };
}

The AndroidClickTargets membership check runs before the type switch, so any element passed through RegisterE2ETapTarget is reported with role="button" regardless of what kind of VisualElement it actually is.

How it manifests through MultiSelectRemoveDialog

MultiSelectRemoveDialog.BuildContent (MultiSelectRemoveDialog.cs:60-69) registers both the row container and the Toggle itself as E2E tap targets:

AccessibilityBridge.RegisterE2ETapTarget(
    row,
    () => toggle.enabledInHierarchy,
    () => ToggleSelection(item.Key, toggle));
AccessibilityBridge.RegisterE2ETapTarget(
    toggle,
    () => toggle.enabledInHierarchy,
    () => ToggleSelection(item.Key, toggle));

RegisterE2ETapTarget on Android unconditionally adds the element to AndroidClickTargets (AccessibilityBridge.cs:185-192). Toggle is a subclass of BaseBoolField, so AndroidNativeRole should return "toggle" — but the ContainsKey early return short-circuits, and the Toggle is published with role="button".

On the Java side (OneSignalUnityE2EAccessibility.java:147-152), createView only instantiates E2ECheckBox in the role.equals("toggle") branch; role="button" creates an E2ETextView with a click handler. Consequences:

  • UiAutomator2 sees android.widget.TextView, not android.widget.CheckBox. UiSelector().className("android.widget.CheckBox") won't match.
  • isChecked() / UiSelector().checked(true) / getAttribute("checked") always return false / unset because TextView is not Checkable.
  • applyText guards the setChecked(...) call with view instanceof E2ECheckBox; that branch is skipped, so the published value "1"/"0" lands as plain text on the TextView and the overlay never reflects the toggle's actual state.

Why the bug doesn't break the existing specs

InvokeAndroidNativeAction (AccessibilityBridge.cs:647-695) tries Q<SwitchToggle>(id), then Q<BaseBoolField>(id) first, and only falls through to the AndroidClickTargets loop if neither matches. The Toggle matches Q<BaseBoolField>, so the value-flip path runs before the click action — clicking the overlay TextView still toggles the underlying Toggle. The PR's numbered specs apparently pass because they assert via content-description / text attribute reads, not via CheckBox state.

Step-by-step proof (single toggle in Remove Selected Tags)

  1. User adds tag foo=bar. tags_card lists the pair.
  2. User taps remove_tags_button. HomeScreenController.ShowRemoveSelectedTagsDialog opens MultiSelectRemoveDialog with one item, ("foo", "bar").
  3. BuildContent creates toggle.name = "remove_checkbox_foo" and calls AccessibilityBridge.RegisterE2ETapTarget(toggle, ...)AndroidClickTargets[toggle] = ....
  4. AccessibilityBridge.SyncAndroidNativeAccessibility walks _map and calls syncElement for the toggle. AndroidNativeRole(toggle) checks AndroidClickTargets.ContainsKey(toggle) → true → returns "button" (it never reaches the BaseBoolField => "toggle" arm).
  5. OneSignalUnityE2EAccessibility.createView sees role.equals("button") → creates E2ETextView, not E2ECheckBox. The overlay does setContentDescription("0") and applyText(view, "0") — TextView branch sets text to "0". CheckBox class is never attached.
  6. Appium calls driver.findElement(AppiumBy.androidUIAutomator("new UiSelector().resourceId(\"remove_checkbox_foo\").checked(false)")). UiAutomator2's accessibility snapshot reports the element's class as android.widget.TextView with no checked attribute — the selector misses.
  7. Even with a coarser selector, element.getAttribute("checked") returns null/false regardless of the Toggle's actual value, because the overlay view isn't a CheckBox.

Impact

  • Demo-only, but the PR's stated motivation is preparing the demo for Appium E2E tests, and Remove Selected Tags / Remove Selected Triggers are the canonical "CheckBox" flows for the test suite to assert.
  • Scope is limited to MultiSelectRemoveDialog today (no other caller registers a BaseBoolField as an Android click target), but the misclassification would silently spread the moment any other Toggle/Radio is wired through RegisterE2ETapTarget.
  • The author's currently-green specs use clicks and text reads, so this regression doesn't block them — but any new spec that uses UiSelector().checked(true), isSelected(), or getAttribute("checked") will fail until this is fixed.

Fix

Run the type switch first, fall through to AndroidClickTargets only for plain VisualElements:

private static string AndroidNativeRole(VisualElement el) => el switch
{
    TextField => "input",
    BaseBoolField => "toggle",
    OneSignalDemo.UI.SwitchToggle => "toggle",
    Button => "button",
    _ when AndroidClickTargets.ContainsKey(el) => "button",
    _ => "text",
};

This preserves the "button" override for unnamed/plain registered targets (e.g. the row container in MultiSelectRemoveDialog, which is also registered as a tap target) while letting typed controls keep their semantic role.

private const string ApsEnvironment = "development";

private const string NseTargetName = "OneSignalNotificationServiceExtension";
private const string WidgetTargetName = "OneSignalWidget";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 🔴 SigningPostProcessor.WidgetTargetName is set to "OneSignalWidget", but BuildPostProcessor.AddAppExtension registers the widget under "OneSignalWidgetExtension" (BuildPostProcessor.cs:48, :128). project.TargetGuidByName("OneSignalWidget") returns null, ApplyExtensionFixup logs the "Target not found" warning and returns, so PRODUCT_BUNDLE_IDENTIFIER is never rewritten to {appId}.LA and DEVELOPMENT_TEAM is never pinned. The widget keeps its default bundle ID com.onesignal.example.OneSignalWidgetExtension, which mismatches the new iOS/ExportOptions.plist mapping for com.onesignal.example.LA → "Appium Demo - Live Activity", so xcodebuild -exportArchive will fail to find a matching provisioning profile. Fix: private const string WidgetTargetName = "OneSignalWidgetExtension";.

Extended reasoning...

What the bug is

examples/demo/Assets/App/Editor/iOS/SigningPostProcessor.cs:37 declares:

private const string WidgetTargetName = "OneSignalWidget";

That string is the widget extension's folder/relative path (WidgetExtensionTargetRelativePath in BuildPostProcessor.cs:46), not the Xcode target name. The Xcode target itself is registered with a different name.

The specific code path that triggers it

BuildPostProcessor.cs:48 defines WidgetExtensionTargetName = "OneSignalWidgetExtension" and BuildPostProcessor.cs:128 calls:

extensionGuid = project.AddAppExtension(
    project.GetUnityMainTargetGuid(),
    WidgetExtensionTargetName,                                  // "OneSignalWidgetExtension"
    $"{PlayerSettings.GetApplicationIdentifier(BuildTargetGroup.iOS)}.{WidgetExtensionTargetName}",
    $"{WidgetExtensionTargetRelativePath}/Info.plist"
);

So the PBX target is literally named OneSignalWidgetExtension with a default bundle ID of {appId}.OneSignalWidgetExtension.

SigningPostProcessor.ApplyExtensionFixup (lines 130–147) then calls project.TargetGuidByName("OneSignalWidget"). No such target exists, so guid is null/empty and the function hits its early-return:

if (string.IsNullOrEmpty(guid))
{
    Debug.LogWarning($"[SigningPostProcessor] Target '{targetName}' not found; skipping.");
    return;
}

Neither SetBuildProperty(guid, "PRODUCT_BUNDLE_IDENTIFIER", bundleId) nor ApplyTeamId(...) runs for the widget target.

Why existing code doesn't prevent it

There is no callsite that resolves the target by file path; TargetGuidByName requires the literal PBX target name. The sibling NseTargetName constant ("OneSignalNotificationServiceExtension") happens to match the SDK's NSE target name, masking the inconsistency for the NSE case. Only the widget constant is wrong.

Step-by-step proof

  1. Unity build emits the Xcode project. BuildPostProcessor (callbackOrder 45) runs first, calls AddAppExtension(..., "OneSignalWidgetExtension", ...). PBX now contains a target named OneSignalWidgetExtension with bundle ID com.onesignal.example.OneSignalWidgetExtension.
  2. SigningPostProcessor (callbackOrder 100) runs. FixupSigningAndBundleIds reads the PBXProject, then invokes ApplyExtensionFixup(project, "OneSignalWidget", "com.onesignal.example.LA").
  3. project.TargetGuidByName("OneSignalWidget") → null. The IsNullOrEmpty branch fires, logs [SigningPostProcessor] Target 'OneSignalWidget' not found; skipping., and returns.
  4. PRODUCT_BUNDLE_IDENTIFIER on the widget target is never rewritten and DEVELOPMENT_TEAM is never pinned. The widget retains com.onesignal.example.OneSignalWidgetExtension.
  5. CI runs xcodebuild -exportArchive -exportOptionsPlist iOS/ExportOptions.plist. ExportOptions.plist declares signingStyle = manual with provisioningProfiles keyed by com.onesignal.example.LA → "Appium Demo - Live Activity". xcodebuild walks the archive's targets, finds the widget's actual bundle ID com.onesignal.example.OneSignalWidgetExtension, looks up that key in the dictionary, finds nothing, and fails with No profile for bundle identifier 'com.onesignal.example.OneSignalWidgetExtension'.

Impact

CI/build-time regression introduced by this PR. The PR description explicitly lists "Normalizes extension bundle IDs to short suffixes (.NSE, .LA)" and adds iOS/ExportOptions.plist keyed by those suffixes; the misnamed constant defeats both. NSE works because its constant happens to match; only the widget is broken.

Fix

One-line change at SigningPostProcessor.cs:37:

private const string WidgetTargetName = "OneSignalWidgetExtension";

Comment on lines +1 to +6
#if UNITY_IOS || UNITY_ANDROID
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Accessibility;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 🔴 AccessibilityBridge.cs wraps its entire class body in #if UNITY_IOS || UNITY_ANDROID (file:1), so the type doesn't exist on Standalone (Windows/Mac/Linux) build targets — but multiple new callers reference it without the same guard: DialogBase.cs (47, 80, 111, 125), SectionBuilder.cs (50, 115, 125, 175, 231), ToastView.cs (39, 48), MultiPairInputDialog.cs (59, 138), MultiSelectRemoveDialog.cs (60, 65), SendIamSectionController.cs (80). On a fresh checkout opened on macOS/Windows, Unity's Active Build Target defaults to Standalone (the per-user EditorUserBuildSettings.asset lives in the gitignored Library/), so neither UNITY_IOS nor UNITY_ANDROID is defined and the demo fails to compile with CS0103 'name AccessibilityBridge does not exist' across ~12 sites until the developer manually switches Build Target to iOS or Android. Fix: drop the file-level gate on AccessibilityBridge.cs and rely on the per-method #if UNITY_ANDROID && !UNITY_EDITOR / #if UNITY_IOS && !UNITY_EDITOR blocks already inside the class, or add stub no-op shells for the public API on non-mobile targets.

Extended reasoning...

What the bug is

examples/demo/Assets/Scripts/Services/AccessibilityBridge.cs is a new file in this PR. Line 1 opens #if UNITY_IOS || UNITY_ANDROID; the matching #endif is at EOF. So the public class AccessibilityBridge : MonoBehaviour declaration plus every static method on it (EnableForE2E, RegisterE2ETapTarget, RequestResync, RequestImmediateResync) only exists when the Active Build Target is iOS or Android.

The author clearly knew this was a constraint: HomeScreenController.cs:65 wraps its EnableForE2E(_root) call in #if UNITY_IOS || UNITY_ANDROID, and SecondaryScreenController.cs:42,72 gates similarly. But several other new callsites added in this PR reference AccessibilityBridge unconditionally:

  • DialogBase.cs:47 (AccessibilityBridge.RequestImmediateResync() in Show), :80 (in Dismiss), :111 (RegisterE2ETapTarget in CreateConfirmButton), :125 (in CreateCancelButton)
  • SectionBuilder.cs:50 (RegisterE2ETapTarget for the info icon), :115, :125 (CreatePrimaryButton/CreateDestructiveButton), :175, :231 (delete buttons inside CreateKeyValueItem/CreateSingleItem)
  • ToastView.cs:39 (RequestImmediateResync in Show), :48 (in Hide)
  • MultiPairInputDialog.cs:59 (RegisterE2ETapTarget for the add-row button), :138 (RequestImmediateResync after add)
  • MultiSelectRemoveDialog.cs:60,65 (RegisterE2ETapTarget for the row + toggle)
  • SendIamSectionController.cs:80 (RegisterE2ETapTarget for IAM buttons)

Why this fires on a fresh checkout

examples/demo/ProjectSettings/EditorBuildSettings.asset only stores the scene list — the Active Build Target is recorded in Library/EditorUserBuildSettings.asset, which is gitignored. So when a new developer clones the repo and opens the demo on macOS or Windows, Unity falls back to its host-OS Standalone default (StandaloneOSX / StandaloneWindows64). Under those targets neither UNITY_IOS nor UNITY_ANDROID is defined, the AccessibilityBridge type symbol vanishes, and every unguarded callsite above fails with CS0103: The name 'AccessibilityBridge' does not exist in the current context.

The using OneSignalDemo.Services; directive in those files resolves to an empty type set when the bridge's body is preprocessed out — there is no companion stub class. The demo Scripts folder has no .asmdef restricting compilation to mobile platforms either, so the same csproj is compiled for whatever the Active Build Target is.

Step-by-step proof

  1. New contributor: git clone …/OneSignal-Unity-SDK && cd examples/demo. There is no Library/ checked in.
  2. Open the project in Unity 6000.4.6f1 on macOS. Unity creates Library/EditorUserBuildSettings.asset defaulting activeBuildTarget to StandaloneOSX.
  3. Unity recompiles Assembly-CSharp.dll. AccessibilityBridge.cs is excluded from compilation by its file-level #if UNITY_IOS || UNITY_ANDROID.
  4. Roslyn reaches DialogBase.cs:47: AccessibilityBridge.RequestImmediateResync();. The AccessibilityBridge identifier resolves to nothing in scope → error CS0103.
  5. Same error repeats at the ~12 other unguarded callsites listed above. Editor reports compile errors and refuses to enter Play mode until the developer manually switches Build Target via File → Build Profiles to iOS or Android.

Why existing code doesn't prevent it

The author's two-pronged gate (file-level on the implementation + #if UNITY_IOS || UNITY_ANDROID around EnableForE2E callsites in screen controllers) only catches the small set of callsites that received explicit guards. The much larger surface introduced in this PR — every dialog and section that registers tap targets / requests resyncs — was not guarded, presumably because the author was developing with Build Target already pinned to iOS or Android. There is no asmdef-level platform constraint to catch the inconsistency mechanically.

Impact

Demo-only — the SDK code under com.onesignal.unity.* is untouched, so production users are not affected. But this is a real first-open foot-gun for new contributors and any reviewer who checks out the branch on macOS or Windows: the demo won't compile, and the fix (switch Active Build Target) is non-obvious to someone who hasn't read the PR description.

Fix

Two clean options, both single-file edits:

  1. Remove the file-level gate on AccessibilityBridge.cs and rely on the per-method internal #if UNITY_ANDROID && !UNITY_EDITOR / #if UNITY_IOS && !UNITY_EDITOR blocks that are already liberally used inside the methods (e.g. RegisterE2ETapTarget's body, SyncAndroidNativeAccessibility). The class would expose the same public surface on every target, with method bodies that no-op on Standalone.
  2. Add stub no-op shells for the public API in a #else branch at the end of the file. Same effect, slightly more code.

Either fix unblocks Standalone compilation without touching the dozen call sites or weakening the existing per-method platform branching.

Comment on lines +663 to +668
var boolField = _root.Q<BaseBoolField>(id);
if (boolField != null)
{
boolField.value = !boolField.value;
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 On Android in E2E mode, clicking an already-selected RadioButton in OutcomeDialog flips its value to false via the unconditional boolField.value = !boolField.value in InvokeAndroidNativeAction (AccessibilityBridge.cs:663-667). Because RadioButton subclasses BaseBoolField and the SelectType callbacks only fire on newValue=true, the radio visually deselects with no other radio becoming active, while _selectedType silently retains its previous value. Fix: special-case RadioButton so re-clicking an already-selected radio is a no-op (e.g. if (boolField is RadioButton && boolField.value) return;) or set value = true rather than flip for RadioButton subclasses.

Extended reasoning...

What the bug is

AccessibilityBridge.InvokeAndroidNativeAction (AccessibilityBridge.cs:663-668) handles all BaseBoolField targets the same way:

var boolField = _root.Q<BaseBoolField>(id);
if (boolField != null)
{
    boolField.value = !boolField.value;
    return;
}

The author's own comment at lines 659-662 acknowledges that BaseBoolField is the common base of Toggle and RadioButton, but applies Toggle's flip semantics to both. UI Toolkit's built-in RadioButton.Clickable manipulator sets value=true unconditionally — a user click on an already-selected radio is a no-op, not a deselect. The bridge's flip diverges from that and can leave the radio group with nothing selected.

Code path that triggers it (Android E2E)

OutcomeDialog uses loose RadioButtons in a plain VisualElement (OutcomeDialog.cs:37) — not a RadioButtonGroup — so Unity's auto-mutex group semantics don't apply. The handler at OutcomeDialog.cs:43-57 only acts on newValue=true:

_normalRadio.RegisterValueChangedCallback(e => { if (e.newValue) SelectType(OutcomeType.Normal); });

The radios are not registered with RegisterE2ETapTarget, so AndroidNativeRole falls through to the BaseBoolField => "toggle" arm (AccessibilityBridge.cs:733). Java creates an E2ECheckBox overlay, click round-trips into InvokeAndroidNativeAction, and the BaseBoolField branch flips the value.

Step-by-step proof

  1. OutcomeDialog opens. _normalRadio.value = true (set by CreateRadio at line 100 before callbacks register, so no callback fires during init). _selectedType = Normal.
  2. Android E2E test taps outcome_type_normal_radio (e.g. to assert the default selection before flowing onward).
  3. Java overlay's OnClickListener sends click to Unity. HandleAndroidAccessibilityAction routes to InvokeAndroidNativeAction("outcome_type_normal_radio").
  4. Q<SwitchToggle> returns null. Q<BaseBoolField> matches _normalRadio (RadioButton extends BaseBoolField).
  5. boolField.value = !true = false. Callback fires with newValue=false. The handler guard if (e.newValue) skips SelectType.
  6. End state: _normalRadio.value = false, _uniqueRadio.value = false, _withValueRadio.value = false. Visually no radio is selected. _selectedType is still Normal (functional flow OK), but ExtractValue now returns "0" for all three radios — so any accessibility assertion against getAttribute("value") reads as if no outcome type is selected.

Why this is narrow

Functional flow still works: SendOutcome reads _selectedType, which is preserved across the flip. The PR's referenced specs (01_ through 12_) don't appear to exercise this re-click pattern on the outcome radios, which is why they pass. The misbehavior only surfaces when a test or user re-clicks an already-selected radio — uncommon in test design, but real and surprising when it hits. Demo-only, E2E-only, no SDK impact.

Fix

One-line guard:

var boolField = _root.Q<BaseBoolField>(id);
if (boolField != null)
{
    if (boolField is RadioButton && boolField.value) return;
    boolField.value = !boolField.value;
    return;
}

Or set value = true rather than flip for RadioButton subclasses to mirror the built-in Clickable manipulator.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant